UITableViewUICollectionView 无疑是 iOS 开发中使用最多的视图控制器,它往往负责展示大量的数据. 伴随着用户的手指不断的滑动,系统会反复刷新UI, 加载所要展示的内容。而一旦数据体积增大,计算的逻辑复杂,就会造成过多的性能浪费。解决这类问题的方法有很多. 最近在 Yep 代码中看到的方法,在应用到自己项目的时候感觉非常不错, 所以有了以下的记录总结.

首先看一下 Demo 要实现的效果

既然要利用代码的方式实现这样的效果,我们现在分析一下实现的具体过程。

  • 图中是一个 TableViewController
  • 有三种不同的 cell 样式
  • 三种不同的 cell 包含一些共同的元素,比如 nickName,avatorImage,还有 comment count,以及 category

我们可以先创建一个基类 FeedBaseCell, 用来定义三种样式的公共元素.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class FeedBaseCell: UITableViewCell {
// 控制文本内容使用的 textView 最大的宽度.
static let messageTextViewMaxWidth: CGFloat = {
let maxWidth = UIScreen.main.bounds.width - (15 + 40 + 10 + 15)
return maxWidth
}()
// 用来配置 cell 时使用的 feed 实例
var feed: Feed?
// 控制 subViews 点击事件
var tapAvataraction: ((FeedBaseCell) -> Void)?
var touchesBeganAction: ((UITableViewCell) -> Void)?
var touchesEndedAction: ((UITableViewCell) -> Void)?
var touchesCancelledAction: ((UITableViewCell) -> Void)?
lazy var avatarImageView: UIImageView = {
let imageView = UIImageView()
// 自定义样式省略...
return imageView
}()
lazy var nickNameLabel: UILabel = {
let label = UILabel()
// 自定义样式省略...
return label
}()
lazy var categoryButton: UIButton = {
let button = UIButton()
// 自定义样式省略...
return button
}()
lazy var messageTextView: FeedTextView = {
let textView = FeedTextView()
// 自定义样式省略...
return textView
}()
lazy var leftBottomLabel: UILabel = {
let label = UILabel()
// 自定义样式省略...
return label
}()
lazy var messageCountLabel: UILabel = {
let label = UILabel()
// 自定义样式省略...
return label
}()

FeedBaseCell 中不仅包换其中的 subviews 实例,还需实现一个 heightForFeed 的方法用来计算 cell 的高度.

1
2
3
4
5
6
7
8
9
10
11
12
/// 通过Feed内容计算高度
class func heightOfFeed(feed: Feed) -> CGFloat {
let rect = (feed.contentBody! as NSString).boundingRect(with:
CGSize(width: FeedBaseCell.messageTextViewMaxWidth, height:
CGFloat(FLT_MAX)), options:
[.usesLineFragmentOrigin, .usesFontLeading], attributes:
[NSFontAttributeName: UIFont.systemFont(ofSize: 17)], context: nil)
let height: CGFloat = 10 + 40 + ceil(rect.height) + 4 + 15 + 17 + 15
return ceil(height)
}

接着我们在 FeedBaseCell 的初始化方法中将 subviews 实例添加到 contentView.

1
2
3
4
5
6
7
8
9
10
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(avatarImageView)
contentView.addSubview(nickNameLabel)
contentView.addSubview(categoryButton)
contentView.addSubview(messageTextView)
contentView.addSubview(leftBottomLabel)
contentView.addSubview(messageCountLabel)
contentView.addSubview(discussionImageView)
}

FeedAnyImagesCellFeedBigImageCell 都继承自 FeedBaseCell. 我们重写其父类的 heightOfFeed 方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class FeedAnyImagesCell: FeedBaseCell {
// 显示 images 的视图创建省略.
override class func heightOfFeed(feed: Feed) -> CGFloat {
let height = super.heightOfFeed(feed: feed) +
Config.FeedAnyImagesCell.imageSize.height + 15
return ceil(height)
}
}
class FeedBiggerImageCell: FeedBaseCell {
// 显示 image 的视图创建省略.
override class func heightOfFeed(feed: Feed) -> CGFloat {
let height = super.heightOfFeed(feed: feed) +
Config.FeedBiggerImageCell.imageSize.height + 15
return ceil(height)
}
}

三种样式的 cell 创建完成后, 我们需要定义一个 FeedCellLayout 的结构体, 用来封装不同 cell 类型所对应的 layout 布局.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
struct FeedCellLayout {
let screenWidth = UIScreen.main.bounds.width
// 三种类型 cell 的公共元素.
struct DefaultLayout {
let avatarImageViewFrame: CGRect
let nicknameLabelFrame: CGRect
let categoryButtonFrame: CGRect
let messageTextViewFrame: CGRect
let leftBottomLabelFrame: CGRect
let messageCountLabelFrame: CGRect
let discussionImageViewFrame: CGRect
}
var defaultLayout: DefaultLayout
struct BiggerImageLayout {
let biggerImageViewFrame: CGRect
}
var biggerImageLayout: BiggerImageLayout?
struct AnyImagesLayout {
let mediaCollectionViewFrame: CGRect
}
var anyImagesLayout: AnyImagesLayout?
// cell的高度
var height: CGFloat = 0
// init
init(feed: Feed) {
/* init 方法中接受一个 feed 实例,
我们通过此实例中的附件类型来调用响应的计算cell高度.
*/
switch feed.attachment {
case .Text:
height = FeedBaseCell.heightOfFeed(feed: feed)
case .Image(let imagesAttachments):
printLog(imagesAttachments)
if imagesAttachments.count > 1 {
height = FeedAnyImagesCell.heightOfFeed(feed: feed)
} else {
height = FeedBiggerImageCell.heightOfFeed(feed: feed)
}
default:
break
}
// 接下来就是根据 Feed 实例所携带的各种信息, 来计算 defaultLayout,
// biggerImageLayout, anyImagesLayout
let avatarImageViewFrame = CGRect(x: 15, y: 10, width: 40, height: 40)
let nicknameLabelFrame: CGRect
let categoryButtonFrame: CGRect
if let category = FeedCategory(rawValue: feed.category) {
let rect = (category.rawValue as NSString).boundingRect(with:
CGSize(width: 320, height: CGFloat(FLT_MAX)), options:
[.usesLineFragmentOrigin, .usesFontLeading], attributes:
Config.FeedDetailCell.categryButtonAttributies, context: nil)
let categoryButtonWidth = ceil(rect.width) + 20
categoryButtonFrame = CGRect(x: screenWidth - categoryButtonWidth -
15, y: 19, width: categoryButtonWidth, height: 22)
let nickNameLabelwidth = screenWidth - 65 - 15
nicknameLabelFrame = CGRect(x: 65, y: 21, width: nickNameLabelwidth,
height: 18)
} else {
let nickNameLabelwidth = screenWidth - 65 - 15
nicknameLabelFrame = CGRect(x: 65, y: 21, width: nickNameLabelwidth,
height: 18)
categoryButtonFrame = CGRect.zero
}
let rect1 = (feed.contentBody! as NSString).boundingRect(with:
CGSize(width: FeedBaseCell.messageTextViewMaxWidth, height:
CGFloat(FLT_MAX)), options: [.usesFontLeading, .usesLineFragmentOrigin],
attributes: Config.FeedDetailCell.messageTextViewAttributies, context:
nil)
let messageTextViewHeight = ceil(rect1.height)
let messageTextViewFrame = CGRect(x: 65, y: 54, width: screenWidth - 65 -
15, height: messageTextViewHeight)
let leftBottomLabelOriginY = height - 17 - 15
let leftBottomLabelFrame = CGRect(x: 65, y: leftBottomLabelOriginY, width:
screenWidth - 65 - 85, height: 17)
let messageCountLabelWidth: CGFloat = 30
let messageConuntLabelFrame = CGRect(x: screenWidth -
messageCountLabelWidth - 39 - 8, y: leftBottomLabelOriginY, width:
messageCountLabelWidth, height: 19)
let discussionImageViewFrame = CGRect(x: screenWidth - 24 - 15, y:
leftBottomLabelOriginY - 1, width: 24, height: 20)
let defaultLayout = FeedCellLayout.DefaultLayout(
avatarImageViewFrame: avatarImageViewFrame,
nicknameLabelFrame: nicknameLabelFrame,
categoryButtonFrame: categoryButtonFrame,
messageTextViewFrame: messageTextViewFrame,
leftBottomLabelFrame: leftBottomLabelFrame,
messageCountLabelFrame: messageConuntLabelFrame,
discussionImageViewFrame: discussionImageViewFrame
)
self.defaultLayout = defaultLayout
let beginY = messageTextViewFrame.maxY + 15
switch feed.attachment {
case .Image(let imagesAttachments):
if imagesAttachments.count > 1 {
let mediaCollectionViewFrame = CGRect(origin: CGPoint(x:65, y:
beginY), size: Config.FeedAnyImagesCell.mediaCollectionViewSize)
self.anyImagesLayout = AnyImagesLayout(mediaCollectionViewFrame:
mediaCollectionViewFrame)
}
if imagesAttachments.count == 1 {
let biggerImageViewFrame = CGRect(origin: CGPoint(x: 65, y:
beginY), size: Config.FeedBiggerImageCell.imageSize)
let biggerImageLayout = BiggerImageLayout(biggerImageViewFrame:
biggerImageViewFrame)
self.biggerImageLayout = biggerImageLayout
}
default:
break
}
}

接下来我们来设计缓存逻辑, 我们定义个结构体, 外部只需要传入 Feed 实例, 就能正确得到其对应的 Layout 布局.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct LayoutCatch {
// 定义一个字典, 存储 feed 对应的 FeedCellLayout, 用 feed 的 bjectID 做 key
var feedCellLayoutHash = [String: FeedCellLayout]()
mutating func feedCellLayoutOfFeed(feed: Feed) -> FeedCellLayout {
let key = feed.objectId ?? ""
if let layout = feedCellLayoutHash[key] {
return layout
} else {
let layout = FeedCellLayout(feed: feed)
updateFeedCellLayout(layout: layout, forFeed: feed)
return layout
}
}
mutating func updateFeedCellLayout(layout: FeedCellLayout, forFeed feed: Feed) {
let key = feed.objectId ?? ""
if !key.isEmpty {
FeedCellLayoutHash[key] = layout
}
}
mutating func heightOfFeed(feed: Feed) -> CGFloat {
let layout = FeedCellLayoutOfFeed(feed: feed)
return layout.height
}
}

缓存逻辑完成之后, 我们为三种不同的 cell 样式分别添加 configureWithFeed(feed: Feed, layout: FeedCellLayout, needshowCategory: Bool) 的方法, 将计算好的 layout 应用到 cell.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
//FeedBaseCell
func configureWithFeed(feed: Feed, layout: FeedCellLayout, needshowCategory:
Bool) {
self.feed = feed
let defaultLayout = layout.defaultLayout
messageTextView.text = "\(feed.contentBody!)"
messageTextView.frame = defaultLayout.messageTextViewFrame
nickNameLabel.text = feed.creator?.username ?? "iTychooo"
nickNameLabel.frame = defaultLayout.nicknameLabelFrame
avatarImageView.image = UIImage(named: "Howard")
avatarImageView.frame = defaultLayout.avatarImageViewFrame
categoryButton.setTitle(feed.category, for: .normal)
categoryButton.frame = defaultLayout.categoryButtonFrame
leftBottomLabel.text = "1小时前"
leftBottomLabel.frame = defaultLayout.leftBottomLabelFrame
discussionImageView.frame = defaultLayout.discussionImageViewFrame
messageCountLabel.text = "10"
messageCountLabel.frame = defaultLayout.messageCountLabelFrame
}
//biggerImageCell
override func configureWithFeed(feed: Feed, layout: FeedCellLayout,
needshowCategory: Bool) {
super.configureWithFeed(feed: feed, layout: layout, needshowCategory:
needshowCategory)
if let biggerImageLayout = layout.biggerImageLayout {
biggerImageView.frame = biggerImageLayout.biggerImageViewFrame
}
switch feed.attachment {
case .Image(let imageAttachments):
if let attachment = imageAttachments.first {
//大图还是使用原始大小的图片.
imageAttachment = attachment
biggerImageView.showActivityIndicatorWhenLoading = true
biggerImageView.cube_setImageAtFeedCellWithAttachment(attachment:
attachment, withSize: nil)
}
default:
break
}
// FeedAnyImagesCell
override func configureWithFeed(feed: Feed, layout: FeedCellLayout,
needshowCategory: Bool) {
super.configureWithFeed(feed: feed, layout: layout, needshowCategory:
needshowCategory)
switch feed.attachment {
case .Image(let imageAttachments):
self.imageAttachments = imageAttachments
default:
break
}
if let anyImagesLayout = layout.anyImagesLayout {
mediaCollectionView.frame = anyImagesLayout.mediaCollectionViewFrame
}
}

最后, 我们在 TableViewController 的 tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell 方法中, 配置 cell 就可以了.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
enum Section: Int {
case uploadingFeed = 0
case Feed
case loadMore
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -
> UITableViewCell {
guard let section = Section(rawValue: indexPath.section) else {
fatalError()
}
func cellForFeed(feed: Feed) -> UITableViewCell {
switch feed.attachment {
case .Text:
let cell = tableView.dequeueReusableCell(withIdentifier:
FeedBaseCellIdentifier, for: indexPath) as! FeedBaseCell
cell.configureWithFeed(feed: feed, layout:
FeedsViewController.layoutCatch.FeedCellLayoutOfFeed(feed: feed),
needshowCategory: false)
return cell
case .Image(let imagesAttachments):
if imagesAttachments.count > 1 {
let cell = tableView.dequeueReusableCell(withIdentifier:
FeedAnyImagesCellIdentifier, for: indexPath) as!
FeedAnyImagesCell
cell.configureWithFeed(feed: feed, layout:
FeedsViewController.layoutCatch.FeedCellLayoutOfFeed(feed:
feed), needshowCategory: false)
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier:
FeedBiggerImageCellIdentifier, for: indexPath) as!
FeedBiggerImageCell
cell.configureWithFeed(feed: feed, layout:
FeedsViewController.layoutCatch.FeedCellLayoutOfFeed(feed: feed),
needshowCategory: false)
return cell
default:
return UITableViewCell()
}
}
switch section {
case .uploadingFeed:
let feed = uploadingFeeds[indexPath.row]
return cellForFeed(feed: feed)
case .Feed:
let feed = feeds[indexPath.row]
return cellForFeed(feed: feed)
case .loadMore:
return tableView.dequeueReusableCell(withIdentifier:
LoadMoreTableViewCellIdentifier, for: indexPath)
}
}

OK, Done!

感谢 Yep 的团队开源如此优秀的代码, 我从中学到了很多. 在浏览了大量 commit 后, 也了解了一个项目从无到有的大概过程, 对于我这种半路出家的自学者, 受益颇多.